React 中 UI 完全是通过状态驱动的。所以在任何时刻,整个应用程序的状态数据就完全决定了当前 UI 上的展现。毫不夸张地说,React 的开发其实就是复杂应用程序状态的管理和开发。因此,这就需要你去仔细思考,该怎么去用最优、最合理的方式,去管理你的应用程序的状态。
所以今天围绕状态一致性这个需求,介绍两个基本原则,它们能帮助我们避免很多复杂的状态管理逻辑,简化应用程序的开发。
新接触 React 的同学经常会有一个错误的习惯,就是把 State 当变量用,很容易把过多的数据放到 State 里,可以说这是对 State 的一种滥用。
那到底该怎么使用 State 呢?我们需要遵循一个原则,即:在保证 State 完整性的同时,也要保证它的最小化。
就是说,某些数据如果能从已有的 State 中计算得到,那么我们就应该始终在用的时候去计算,而不要把计算的结果存到某个 State 中。这样的话,才能简化我们的状态处理逻辑。
举个例子。你要做一个功能,需要对一个列表的结果进行关键字搜索,我们假设是一个显示电影标题的列表,需要能够对标题进行搜索。最终的效果如下图所示:

可以看到,这个功能包括一个搜索框和一个电影标题的列表。那么,在考虑怎么实现这个功能的时候,应该从哪里着手呢?
按照 React 的状态驱动 UI 的思想,第一步就是要考虑整个功能有哪几个状态。直观上来说,页面可能包含三个状态:
电影列表的数据:可能来自某个 API 请求;
用户输入的关键字:来自用户的输入;
搜索的结果数据:来自原始数据结合关键字的过滤结果。
那么很多同学这时候就会在组件中去定义这三个状态,一般的实现代码如下:
看上去没有太大问题,整段代码也能正常工作。但是如果仔细思考,你会发现其中隐藏的一致性的问题:展示的结果数据完全由原始数据和关键字决定,而现在却作为一个独立的状态去维护了。这意味着你始终要在原始数据、关键字和结果数据之间保证一致性。
在代码中,我们已经做了一部分维护一致性的工作,那就是当关键字变化时,我们会同时更新关键字和最终结果这两个状态,从而让这两个状态始终保持一致。
不过还有部分一致性的工作没有被考虑到,那就是如果原始数据 data 属性变化了,最终的结果却没有使用新的数据。
这个时候你可能就又会问了:我在处理关键字变化的同时,再处理一下 data 属性变化的场景,这样不就可以保证三个状态的一致性了吗?比如再加上下面这段代码。
现在,我们终于能够保证三个状态的一致性了,整个搜索列表也能正常工作了!但是我们在获得一些成就感的同时,是不是也有一些小小的抱怨:这状态管理确实还挺复杂的,需要写这么多的逻辑来保证一致性,从而让功能正常工作。
那么,我想说的是,这种复杂性其实完全不需要。因为复杂性的根源就在于没有遵循状态最小化的原则,而是设计了一个多余的状态:过滤后的结果数据。由于这个结果数据实际上完全由原始数据和过滤关键字决定,那么我们在需要的时候每次重新计算得出就可以了。
那时候你可能又有疑问了,如果每次都计算,不是会有性能问题吗?其实在第 4 讲我已经提到,React 提供的 useMemo 这个 Hook 正是为了解决这个问题,可以缓存计算的结果。所以实现的代码可以修改如下:
可以看到,除了通过属性传递进来的 data 状态,我们只定义了一个 searchKey 这个状态。然后通过计算的方式,就可以得到最终需要展现的结果。这样,状态的一致性就得到了天然的保证。你看,通过使用状态最小化的原则,管理就变得非常简单了。
虽然这是一个比较简单的例子,但是在实际开发的过程中,很多复杂场景之所以变得复杂,如果抽丝剥茧来看,你会发现它们都有定义多余状态现象的影子,而问题的根源就在于它们没有遵循状态最小化的原则。
所以我们在定义一个新的状态之前,都要再三拷问自己:**这个状态是必须的吗?是否能通过计算得到呢?**在得到肯定的回答后,我们再去定义新的状态,就能避免大部分多余的状态定义问题了,也就能在简化状态管理的同时,保证状态的一致性。
上面的例子其实定义的多余状态比较明显,但在有的场景下,特别是原始状态数据来自某个外部数据源,而非 state 或者 props 的时候,冗余状态就没那么明显。这时候你就需要准确定位状态的数据源究竟是什么,并且在开发中确保它始终是唯一的数据源,以此避免定义中间状态。
还是拿刚才讲的可搜索电影列表的例子来说,我们需要在用户体验上做一个改进,要让搜索的结果做到可分享。这个功能就类似 Baidu 这样的搜索引擎,通过一个链接就能分享搜索结果。比如说,你通过"http://www.baidu.com/s?wd=极客时间"就可以看到极客时间的搜索结果。
想象一下,如果搜索引擎没有这个功能,那使用起来会有多么不方便。每次要分享一个搜索结果,都必须告诉别人关键字是什么,让他 / 她自己打开 Baidu 去搜索。所以将关键字放到 URL 中也是实际开发中经常遇到的一个需求。
那么要实现这个功能,我们就需要让 URL 中包含搜索关键字的信息,这样任何人用这个 URL ,就都能看到和我一样的搜索关键字和结果了。
在考虑这个功能实现,尤其是基于已有功能做改进的时候,通常来说,直观的思路都是:首先把 URL 上的参数数据保存在一个 State 中,当 URL 变化时,就去改变这个 State。然后在组件中再根据这个 State 来实现搜索的业务逻辑。
按照这个思路来改进电影列表的话,我们就可以用类似下面的代码来实现:
可以看到,组件的核心逻辑基本没变,只是做了两个小的变化:
把 URL 上的查询参数作为关键字的默认值;
当用户输入搜索关键字时,我们不但更新了内部 State,同时还改变了 URL 的查询参数。
通过这两个小的变化,我们就实现了搜索结果的可分享。看上去似乎没有什么问题:保证了关键字状态,还有 URL 参数的一致性。但是正如我刚才强调的,一旦涉及到主动保持一致性的逻辑,我们就要考虑状态是否真的有必要。
所以我们再仔细思考下,就会发现上面的逻辑是有漏洞的。因为从 URL 参数到内部 State 的同步只有组件第一次渲染才会发生,而后面的同步则是由输入框的 onChange 事件保证的,那么一致性就很容易被破坏。
也就是说,如果 URL 不是由用户在组件内搜索栏去改变的,而是其它地方,比如说组件外的某个按钮去触发改变的,那么组件由于已经渲染过了,其实内部的 searchKey 这个 State 是不会被更新的,一致性就会被破坏。
要解决这个问题,一个比较容易想到的思路就是我们要有更加完善的机制,让在 URL 不管因为什么原因而发生变化的时候,都能同步查询参数到 searchKey 这个 State。但是如果沿着这个思路,那么状态管理就会一下子变得非常复杂。因为我们需要维护三个状态的一致性。
其实,从根源上来说,产生这个复杂度的问题在于我们定义了 searchKey 这样一个多余的中间状态,而且这个 searchKey 状态来源于两个数据源:一是用户输入;二是 URL 上的参数。这就导致逻辑非常复杂。
那么如果我们遵循唯一数据源这个原则,把 URL 上的查询关键字作为唯一数据源,逻辑就会变得简单了:只需要在用户输入查询关键字时,直接去改变 URL 上的查询字符串就行。这个转变可以用下面的图做一个对比,你会更直观地理解:

通过对比可以看到,左边引入了一个多余的 State 作为关键字这个状态,而为了保证一致性,就需要很多复杂的同步逻辑,比如说以下几点:
但是,在去掉多余的 State 后,我们就只需要在输入框和 URL 的查询参数之间做一个同步。那么实现的代码可以简化如下:
可以看到,当用户输入参数的时候,我们是直接改变当前的 URL,而不是去改变一个内部的状态。所以当 URL 变化的时候,我们使用了 useSearchParam 这样一个第三方的 Hook 去绑定查询参数,并将其显示在输入框内,从而实现了输入框内容和查询关键字这个状态的同步。
从本质上来说,这个例子展示了确保状态唯一数据源的重要性。我们是直接将 URL 作为唯一的数据来源,那么状态的读取和修改都是对 URL 直接进行操作,而不是通过一个中间的状态。这样就简化了状态的管理,保证了状态的一致性。
在前面两个例子中,你看到了状态管理的两个很重要的原则,一个是确保状态最小化,另一个则是找到正确的状态来源,并直接使用这个来源,避免中间状态。那么接下来我再通过一个日常开发中非常常见的例子,来帮助你理解和掌握这两个原则的实际应用。
这个例子就是创建一个受控表单组件。比如,一个用于输入价格的表单组件,需要用户既能输入价格的数量,还能选择货币的种类,最终的效果类似下面这个图:

首先我要解释下什么是受控组件。在 React 中,对表单组件的处理可以分为两种,受控组件和非受控组件:
受控组件:组件的展示完全由传入的属性决定。比如说,如果一个输入框中的值完全由传入的 value 属性决定,而不是由用户输入决定,那么就是受控组件,写法是:<input value={value} onChange={handleChange} />
。这也是为什么只给 < input/>
传了一个 value 值但是没有传 onChange 事件,那么键盘怎么输入都没有反应。
非受控组件:表单组件可以有自己的内部状态,而且它的展示值是不受控的。比如 input 在非受控状态下的写法是:<input onChange={handleChange}/>
。也就是说,父组件不会把 value 直接传递给 input 组件。
在日常开发中,大部分的表单元素其实都是受控组件,我们会通过外部的状态完全控制当前组件的行为。
那么对于这个例子,价格输入框作为一个受控组件,它需要定义两个属性:value 和 onChange。这样它就和一个普通的表单元素具有相同用法了。其中 value 的值是一个对象,同时包含数值和货币两个属性,比如:
那现在我们就来看如何实现这个受控组件。在这里我就不再演示错误的写法是什么样的了,而是直接给你看正确的实现方式。
在实际项目中,我经常看到很多同学会把这个简单的功能做得逻辑非常复杂,甚至还不断出现 Bug,总不能保证 UI 和数据的一致性。这其实都是因为没有仔细思考状态的准确来源是什么,以及是否定义了多余的状态。
我们先直接来看正确的实现:
可以看到,这个自定义组件包含了 input 和 select 两个基础组件,分别用来输入价格数量和选择货币。在它们发生变化的时候,直接去触发 onChange 事件让父组件去修改 value 值;同样的,它们自己显示的值,则完全来自于传递进来的 value 属性。所以这其中的思考逻辑在于:
避免多余的状态:我们不需要在 PriceInput 这个自定义组件内部,去定义状态用于保存的 amount 或者 currency。
找到准确的唯一数据源:这里内部两个基础组件的值,其准确且唯一的来源就是 value 属性,而不是其它的任何中间状态。
因此,通过这样的做法,整个自定义的组件逻辑就变得非常简单,甚至不需要任何内部的状态,就实现了这样一个非常常见的需求。
而在我看到的实际项目代码中,发现很多同学都习惯于用多余的内部状态去分别保存 amount 和 currency,然后再和外部传进来的 value 属性进行同步,以此来保证一致性,这就造成了状态逻辑的复杂。所以我们在做类似功能的时候,一定要避免这种不合理的做法,尽量用最简洁的逻辑去实现需要的功能。